本文主要介绍开发中使用到的构建工具。
TaskRunner#
grunt & gulp#
grunt 是一个构建任务执行器、通过插件机制进行扩展,通过 Gruntfile(js)声明任务的各种配置,再结合 cli 进行构建。
gulp 类似于 grunt,也是一个任务执行器,通过 gulpfile.js 来声明各种任务。
两者工具流都是如此。
bundler#
打包的目的,从入口开始解析依赖。
minipack
对于绝大部分项目,基本都会包含对其他包的依赖。因此在打包过程需要将使用到的依赖也一并打包。这还涉及到不同的模块化方案,因此涉及到解析源码,读取 AST,将模块化方案替换(也可以通过 polyfill 实现,如 browserify)成适用于打包目标的情况(如 AMD/CommonJS 的替换)。
最初 browserify 实现了一个 CommonJS 的 polyfill,然后将依赖到的代码进行拼接,得到最终产物,依赖解析这一步并不独立。随着需求变更,现在更好的实现是生成一个依赖图,提供给其他步骤使用。
browserify#
nodejs 为 JS 开发带来了模块化能力,通过 require
语句即可引用别的模块。同时带有变量隔离等好用的特性。但在那时,浏览器没有这样的能力,因此可以通过打包到一个文件中,实现类似的效果 browserify 就是这样做的。
browserify at v1.0查看 v1.0 的代码可以发现,最初的流程十分简单,提供一些 polyfill
,根据 package.json 获取依赖将依赖代码进行拼接,将业务代码进行拼接得到最终结果。一个browserify.js
文件就是最终产物。
Rollup#
相比于 Webpack,vite 这样的开箱即用的工具,Rollup 是一个相对纯粹的打包工具。通过插件机制(类似于 ESBuild 的插件 API,但是提供了更多对构建的控制),实现对产物的和打包。
整个打包流程如图所示。
打包时,从入口开始,依次开始解析依赖,然后加载,如果内容中有新的 import,继续解析依赖,加载之后可以对内容进行转换得到最终结果。
tsup#
尽管一些 bundler 有着插件机制来实现很 fancy 的功能,但是还有一些情况下,实际上不需要那么多的功能,只需要一些最简单的内置集成。tsup 就是如此,它基于 esbuild,提供一些内置支持,保持简单性,对于小项目非常合适。
All in one#
相比于独立的 Bundler、TaskRunner,需要了解的概念是在是太多了,需要配置的也太多了。因此催生出了像 Webpack 这样集各种功能于一身的 “缝合怪”,试图提供开箱即用的体验,同时通过插件机制保留其拓展性。
Deps Resolver#
对于绝大部分项目,基本都会包含对其他包的依赖。因此在打包过程需要将使用到的依赖也一并打包。这还涉及到不同的模块化方案,因此在一些情况下涉及解析源码,读取 AST,将模块化方案替换(也可以通过 polyfill 实现,如 browserify)成适用于打包目标的情况(如 AMD/CommonJS 的替换)。
最初 browserify 实现了一个 CommonJS 的 polyfill,然后将依赖到的代码进行拼接,得到最终产物,依赖解析这一步并不独立。随着需求变更,现在更好的实现是生成一个依赖图,提供给其他步骤使用。
CodeSplitting#
在项目代码打包到一起之后,确实只需要一个请求就可以加载所有的脚本内容了,但是在大型项目中,这个最终产物有可能变得非常大。而实际情况下,很多代码也不会在一开始就用上。
因此将代码拆分为多个部分,最终产物可以拆成多个文件,在需要的时候进行加载。
代码拆分的基础是动态导入 (异步)。
// main.js
// static import
import doSomething from './lib1.js';
doSomething();
if (condition) {
await import('/lib2.js');
}
当打包器检测到动态导入时,可以生成两部分main.chunk.js
和 lib2.chunk.js
产物。
在浏览器执行main.chunk.js
时,如果条件匹配,就下载 lib.chunk.js
。
这样可以在一定程度上减轻首屏渲染的压力。
Note
在拆分成多个产物的情况下,浏览器完成了首屏渲染后,可以在空闲时对产物进行预取 / 预加载,减少动态导入时所需要的等待时间。
DeadCodeElimination/TreeShaking#
从实际情况来看,代码中通常会存在一些没有使用到的部分。可能是开发过程中为以后的特性预留的,也可能是依赖中没有使用到的部分。这些部分代码在最终产物中是无用的,因此可以清除掉。
最初的各种实现思路中,主要是通过静态分析分析出没有使用过的代码,随后进行清除,因此最开始称为DeadCodeElimination
。但这样存在的问题是,可能出现无法判断的情况,无法确认是否可以清除而不影响主要内容,只得保留。
后来转变了思路,从分析无用代码转变为分析使用过的代码,只保留使用过的部分,这种功能被称为TreeShaking
。
Note
值得注意的是,TreeShaking 与 ES 搭配有更好的效果,相比于 CommonJS,其静态分析难度更低。
Compressing/obfuscating#
在实际开发中,为了可读性,我们可能会为代码附带很长的注释、有语义的变量名 / 函数名。这很好,也很有用。但是在实际执行中,浏览器不会关心这些,因此我们可以将这些对浏览器无用的信息删除 / 替换,从而压缩产物大小。
另外一方面,为了安全性,提高逆向的难度,可以对产物进行代码混淆。
DevServer#
在实际开发(Web 开发)中,我们需要模拟一个环境,以模拟用户的体验,从而提高功能开发的正确性。
最初时,可以通过完全模拟的方式。每当代码发生变更时,手动走完完整的编译、打包、部署流程,然后手动进行测试。这样的反馈链路太耗时了。
所以构建工具通常会内置一个 Dev Server,然后通过监控开发文件变化,每当保存时,重新触发编译、打包,随后手动刷新页面,即可得到更新后的内容。但是这还是需要手动刷新,意味着页面上原有的状态都会被清空,重新加载。这在页面内容很多的情况下也是一个高负载。
HMR(Hot Module Replacement)#
全量刷新的成本很高,因此有人提出只替换更新的部分。在浏览器端引入一个 HMR Runtime
,每当更新时,通知该模块,将更新的信息交个它,由该模块对页面上需要更新的内容进行替换。这称为 HMR。
即使是这样,在大型项目中,HMR 的反馈链路可能还是很长。与此同时,浏览器对 ES 模块化也逐渐有了相对成熟的支持。因此我们可以回归原始,取消 bundler
的操作。直接将编译后基于 ES 模块的 js 文件更新同步到 HMR Runtime。实现 Unbundled Development。这可以大幅提升 HMR 的响应速度。
以 snowpack 的图为例。
Note
值得注意的是,在 unbundled 情况下,开发仍会遇到前面提到的问题,初始加载页面是会发起很多请求。这对用户客户端是个高负载,但但这是在本地开发环境的,同时有 HTTP2.0 支持来缓解大量并发请求的问题,这是可以接受的。
Webpack#
Webpack 是一个模块打包器。它完成了开发中大多数复杂的任务,与 npm script
一起,取代了 TaskRunner。Bundler 作为开发 / 生产前的最后一道工序,Webpack 基于此提供了一个开箱即用的体验。
Webpack 的核心概念是 Loader,它将前置步骤抽象为 Loader,让前置步骤成为为插件的一部分。
对于一个打包任务,通过 Loader 进行处理, Webpack 将其视为一个模块,然后根据模块信息输出不同的产物。
Vite#
与 Webpack 所处的原始时代不同,Vite 作为新一代的工具链的重要角色,无疑是站在了巨人的肩膀上。Vite 使用 ESBuild 做依赖预构建、转换、压缩,结合浏览器的原生 ESM 支持,实现了极快的 HMR,用 Rollup 做生产构建,依赖其插件系统,提供了极其强大的能力。